From 95f78109480cc8abd91ab87b5eadc46c09e0ac35 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 31 Jul 2026 22:22:49 +0530 Subject: [PATCH 01/23] 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. (cherry picked from commit e8df7b4a901dc3255ad28bcb183656b348cfb14d) # Conflicts: # erpnext/stock/doctype/material_request/mapper.py # erpnext/stock/doctype/material_request/material_request.js --- .../stock/doctype/material_request/mapper.py | 455 ++++++++++++++++++ .../material_request/material_request.js | 121 +++++ 2 files changed, 576 insertions(+) create mode 100644 erpnext/stock/doctype/material_request/mapper.py diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py new file mode 100644 index 00000000000..7d192b30b44 --- /dev/null +++ b/erpnext/stock/doctype/material_request/mapper.py @@ -0,0 +1,455 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import json + +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 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, +) + + +def set_missing_values(source, target_doc): + if target_doc.doctype == "Purchase Order" and getdate(target_doc.schedule_date) < getdate(nowdate()): + target_doc.schedule_date = None + target_doc.run_method("set_missing_values") + target_doc.run_method("calculate_taxes_and_totals") + + +def update_item(obj, target, source_parent): + target.conversion_factor = obj.conversion_factor + + qty = obj.ordered_qty or obj.received_qty + target.qty = flt(flt(obj.stock_qty) - flt(qty)) / target.conversion_factor + target.stock_qty = target.qty * target.conversion_factor + if getdate(target.schedule_date) < getdate(nowdate()): + target.schedule_date = None + + if target.fg_item: + target.fg_item_qty = obj.stock_qty + if sc_bom := get_subcontracting_boms_for_finished_goods(target.fg_item): + target.item_code = sc_bom.service_item + target.uom = sc_bom.service_item_uom + target.conversion_factor = ( + frappe.db.get_value( + "UOM Conversion Detail", + {"parent": sc_bom.service_item, "uom": sc_bom.service_item_uom}, + "conversion_factor", + ) + or 1 + ) + target.qty = target.fg_item_qty * sc_bom.conversion_factor + target.stock_qty = target.qty * target.conversion_factor + + +@frappe.whitelist() +def make_purchase_order( + source_name: str, target_doc: str | dict | Document | None = None, args: dict | str | None = None +): + if args is None: + args = frappe.flags.args or {} + args = frappe.parse_json(args) + + is_subcontracted = ( + frappe.db.get_value("Material Request", source_name, "material_request_type") == "Subcontracting" + ) + + 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): + filtered_items = args.get("filtered_children", []) + child_filter = d.name in filtered_items if filtered_items else True + + qty = d.ordered_qty or d.received_qty + + return qty < d.stock_qty and child_filter + + def generate_field_map(): + field_map = [ + ["name", "material_request_item"], + ["parent", "material_request"], + ["sales_order", "sales_order"], + ["sales_order_item", "sales_order_item"], + ["wip_composite_asset", "wip_composite_asset"], + ] + + if is_subcontracted: + field_map.extend([["item_code", "fg_item"], ["qty", "fg_item_qty"]]) + else: + field_map.extend([["uom", "stock_uom"], ["uom", "uom"]]) + + return field_map + + doclist = get_mapped_doc( + "Material Request", + source_name, + { + "Material Request": { + "doctype": "Purchase Order", + "validation": { + "docstatus": ["=", 1], + "material_request_type": ["in", ["Purchase", "Subcontracting"]], + }, + }, + "Material Request Item": { + "doctype": "Purchase Order Item", + "field_map": generate_field_map(), + "field_no_map": ["item_code", "item_name", "qty"] if is_subcontracted else [], + "postprocess": update_item, + "condition": select_item, + }, + }, + target_doc, + postprocess, + ) + + doclist.set_onload("load_after_mapping", False) + return doclist + + +@frappe.whitelist() +def make_request_for_quotation(source_name: str, target_doc: str | dict | Document | None = None): + doclist = get_mapped_doc( + "Material Request", + source_name, + { + "Material Request": { + "doctype": "Request for Quotation", + "validation": {"docstatus": ["=", 1], "material_request_type": ["=", "Purchase"]}, + }, + "Material Request Item": { + "doctype": "Request for Quotation Item", + "field_map": [ + ["name", "material_request_item"], + ["parent", "material_request"], + ["project", "project_name"], + ["cost_center", "cost_center"], + ], + }, + }, + target_doc, + ) + + 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 = [ + d.parent + for d in frappe.db.get_all( + "Item Default", {"default_supplier": supplier, "parenttype": "Item"}, "parent" + ) + ] + + return supplier_items + + +@frappe.whitelist() +def make_purchase_order_based_on_supplier( + source_name: str, target_doc: str | dict | Document | None = None, args: dict | None = None +): + mr = source_name + + supplier_items = get_items_based_on_default_supplier(args.get("supplier")) + + def postprocess(source, target_doc): + target_doc.supplier = args.get("supplier") + if getdate(target_doc.schedule_date) < getdate(nowdate()): + target_doc.schedule_date = None + target_doc.set( + "items", + [d for d in target_doc.get("items") if d.get("item_code") in supplier_items and d.get("qty") > 0], + ) + + set_missing_values(source, target_doc) + + target_doc = get_mapped_doc( + "Material Request", + mr, + { + "Material Request": { + "doctype": "Purchase Order", + }, + "Material Request Item": { + "doctype": "Purchase Order Item", + "field_map": [ + ["name", "material_request_item"], + ["parent", "material_request"], + ["uom", "stock_uom"], + ["uom", "uom"], + ], + "postprocess": update_item, + "condition": lambda doc: doc.ordered_qty < doc.qty, + }, + }, + target_doc, + postprocess, + ) + + return target_doc + + +@frappe.whitelist() +def make_supplier_quotation(source_name: str, target_doc: str | dict | Document | None = None): + def postprocess(source, target_doc): + set_missing_values(source, target_doc) + + doclist = get_mapped_doc( + "Material Request", + source_name, + { + "Material Request": { + "doctype": "Supplier Quotation", + "validation": {"docstatus": ["=", 1], "material_request_type": ["=", "Purchase"]}, + }, + "Material Request Item": { + "doctype": "Supplier Quotation Item", + "field_map": { + "name": "material_request_item", + "parent": "material_request", + "sales_order": "sales_order", + }, + }, + }, + target_doc, + postprocess, + ) + + doclist.set_onload("load_after_mapping", False) + return doclist + + +@frappe.whitelist() +def make_stock_entry(source_name: str, target_doc: str | dict | Document | None = None): + def update_item(obj, target, source_parent): + qty = ( + flt(flt(obj.stock_qty) - flt(obj.ordered_qty)) / target.conversion_factor + if flt(obj.stock_qty) > flt(obj.ordered_qty) + else 0 + ) + target.qty = qty + target.transfer_qty = qty * obj.conversion_factor + target.conversion_factor = obj.conversion_factor + + if ( + source_parent.material_request_type == "Material Transfer" + or source_parent.material_request_type == "Customer Provided" + ): + target.t_warehouse = obj.warehouse + else: + target.s_warehouse = obj.warehouse + + if source_parent.material_request_type == "Customer Provided": + target.allow_zero_valuation_rate = 1 + + if source_parent.material_request_type == "Material Transfer": + target.s_warehouse = obj.from_warehouse + + def set_missing_values(source, target): + target.purpose = source.material_request_type + target.from_warehouse = source.set_from_warehouse + target.to_warehouse = source.set_warehouse + if source.material_request_type == "Material Issue": + target.from_warehouse = source.set_warehouse + target.to_warehouse = None + + if source.job_card: + target.purpose = "Material Transfer for Manufacture" + + if source.work_order: + target.purpose = "Material Transfer for Manufacture" + + if source.material_request_type == "Customer Provided": + target.purpose = "Material Receipt" + + target.set_transfer_qty() + target.set_actual_qty() + target.calculate_rate_and_amount(raise_error_if_no_rate=False) + target.stock_entry_type = target.purpose + + if source.job_card: + job_card_details = frappe.get_all( + "Job Card", filters={"name": source.job_card}, fields=["bom_no", "for_quantity"] + ) + + if job_card_details and job_card_details[0]: + target.bom_no = job_card_details[0].bom_no + target.fg_completed_qty = job_card_details[0].for_quantity + target.from_bom = 1 + + if source.work_order: + work_order_details = frappe.db.get_value( + "Work Order", source.work_order, ["bom_no", "use_multi_level_bom"], as_dict=True + ) + + if work_order_details: + target.bom_no = work_order_details.bom_no + target.use_multi_level_bom = work_order_details.use_multi_level_bom + target.from_bom = 1 + # not fg-qty-driven, mirrors the Pick List -> Stock Entry transfer for this Work Order + target.fg_completed_qty = 0 + + doclist = get_mapped_doc( + "Material Request", + source_name, + { + "Material Request": { + "doctype": "Stock Entry", + "validation": { + "docstatus": ["=", 1], + "material_request_type": [ + "in", + ["Material Transfer", "Material Issue", "Customer Provided"], + ], + }, + }, + "Material Request Item": { + "doctype": "Stock Entry Detail", + "field_map": { + "name": "material_request_item", + "parent": "material_request", + "uom": "stock_uom", + "job_card_item": "job_card_item", + }, + "field_no_map": ["expense_account"], + "postprocess": update_item, + "condition": lambda doc: ( + flt(doc.ordered_qty, doc.precision("ordered_qty")) + < flt(doc.stock_qty, doc.precision("ordered_qty")) + ), + }, + }, + target_doc, + set_missing_values, + ) + + return doclist + + +@frappe.whitelist() +def create_pick_list(source_name: str, target_doc: str | dict | Document | None = None): + def update_item(obj, target, source_parent): + qty = flt((obj.stock_qty - obj.picked_qty) / target.conversion_factor, obj.precision("qty")) + target.qty = qty + target.stock_qty = qty * obj.conversion_factor + target.conversion_factor = obj.conversion_factor + + doc = get_mapped_doc( + "Material Request", + source_name, + { + "Material Request": { + "doctype": "Pick List", + "field_map": {"material_request_type": "purpose"}, + "validation": {"docstatus": ["=", 1]}, + }, + "Material Request Item": { + "doctype": "Pick List Item", + "field_map": { + "name": "material_request_item", + "stock_qty": "stock_qty", + "from_warehouse": "warehouse", + }, + "postprocess": update_item, + "condition": lambda doc: ( + flt(doc.picked_qty, doc.precision("picked_qty")) + < flt(doc.stock_qty, doc.precision("stock_qty")) + ), + }, + }, + target_doc, + ) + + doc.set_item_locations() + + return doc + + +@frappe.whitelist() +def make_in_transit_stock_entry(source_name: str, in_transit_warehouse: str): + ste_doc = make_stock_entry(source_name) + ste_doc.add_to_transit = 1 + ste_doc.to_warehouse = in_transit_warehouse + + for row in ste_doc.items: + row.t_warehouse = in_transit_warehouse + + return ste_doc diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 7dab81beba1..549ee14f0c8 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -411,13 +411,134 @@ frappe.ui.form.on("Material Request", { }, make_purchase_order: function (frm) { +<<<<<<< HEAD frappe.model.open_mapped_doc({ method: "erpnext.stock.doctype.material_request.material_request.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, + }); + }, +>>>>>>> e8df7b4a90 (feat: select a supplier per item when creating Purchase Orders from Material Request) }); }, + 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.material_request.make_request_for_quotation", From f4d3b2771b3e41a4c6e32e13662603c30fd6931a Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 31 Jul 2026 22:22:55 +0530 Subject: [PATCH 02/23] 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. (cherry picked from commit 65be201ed6e8df7d80ca33369732926490421de5) # Conflicts: # erpnext/stock/doctype/material_request/test_material_request.py --- .../material_request/test_material_request.py | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index b23118f524c..6028773627f 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -1189,6 +1189,202 @@ class TestMaterialRequest(ERPNextTestSuite): self.assertEqual(material_request.status, "Transferred") self.assertEqual(material_request.transfer_status, "Completed") +<<<<<<< HEAD +======= + def test_check_modified_date_detects_concurrent_modification(self): + """check_modified_date must raise when the in-memory doc is stale vs the DB modified + timestamp. Covers the converted get_value + get_datetime comparison that replaced the + raw MariaDB-only TIMEDIFF (which errors on Postgres); update_status() runs this guard.""" + from frappe.utils import add_to_date, get_datetime + + mr = make_material_request(qty=10) + + fresh = frappe.get_doc("Material Request", mr.name) + # modified matches the DB row -> guard passes. + fresh.check_modified_date() + + # Stale in-memory modified -> concurrent-modification guard must fire. + fresh.modified = add_to_date(get_datetime(fresh.modified), seconds=-120) + with self.assertRaises(frappe.ValidationError) as cm: + fresh.check_modified_date() + self.assertIn("has been modified", str(cm.exception)) + + def test_validate_qty_against_so_blocks_over_request(self): + """validate_qty_against_so must block requesting more than the Sales Order qty, net of + already-indented submitted MRs. Covers the converted Sales Order Item and Material Request + Item SUM queries. (The guard is currently not wired into validate(), so call it directly.)""" + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + + item_code = "_Test Item" + so = make_sales_order(item_code=item_code, qty=10) # submitted -> SO Item stock_qty 10 + + def _mr_against_so(qty): + mr = frappe.new_doc("Material Request") + mr.material_request_type = "Purchase" + mr.company = "_Test Company" + mr.append( + "items", + { + "item_code": item_code, + "qty": qty, + "uom": "_Test UOM", + "conversion_factor": 1, + "schedule_date": today(), + "warehouse": "_Test Warehouse - _TC", + "sales_order": so.name, + }, + ) + return mr + + # An already-submitted MR consuming 6 of the SO's 10. + mr1 = _mr_against_so(6) + mr1.insert() + mr1.submit() + + # A new request for 5 more -> already_indented 6 + 5 = 11 > 10 -> must throw. + over = _mr_against_so(5) + over.insert() + with self.assertRaises(frappe.ValidationError) as cm: + over.validate_qty_against_so() + self.assertIn("maximum", str(cm.exception)) + + # Exactly within the remaining 4 -> 6 + 4 = 10, not greater -> no throw. + over.items[0].qty = 4 + over.validate_qty_against_so() + + def test_get_material_requests_based_on_supplier(self): + """The supplier-based Material Request picker must run on every engine. + + It deduplicated requests with SELECT DISTINCT while ordering by an item + column that is not in the select list; PostgreSQL rejects that, so the + picker has to group and order by an aggregate instead. + """ + from erpnext.stock.doctype.material_request.material_request import ( + get_material_requests_based_on_supplier, + ) + + item = create_item("_Test MR Default Supplier Item") + item.set("item_defaults", []) + item.append( + "item_defaults", + { + "company": "_Test Company", + "default_warehouse": "_Test Warehouse - _TC", + "default_supplier": "_Test Supplier", + }, + ) + item.save() + + mr1 = make_material_request(item_code=item.name, qty=5) + mr2 = make_material_request(item_code=item.name, qty=7) + + result = get_material_requests_based_on_supplier( + doctype="Material Request", + txt="", + searchfield="name", + start=0, + page_len=20, + filters={"supplier": "_Test Supplier", "company": "_Test Company"}, + ) + returned = {row["name"] for row in result} + 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 + +>>>>>>> 65be201ed6 (test: supplier selection when creating Purchase Orders from Material Request) def get_in_transit_warehouse(company): if not frappe.db.exists("Warehouse Type", "Transit"): From 2c8c375ca6ec43bf9812a3260effae948ebe1701 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:38:10 +0530 Subject: [PATCH 03/23] 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. (cherry picked from commit da83370c5c53d8fed01cde60017c0f39319d0ee4) --- .../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 549ee14f0c8..1aefaefd514 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -445,6 +445,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", @@ -455,8 +457,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: [ { @@ -481,11 +483,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, }, @@ -503,15 +510,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 f19aa957cb22e5030478047472f1aab2ebd8d8a3 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:38:15 +0530 Subject: [PATCH 04/23] 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. (cherry picked from commit 09cfd1fe91a3963f1ee23dfb47f8a62b2aacb71c) --- .../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 6028773627f..c0ac04301cc 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 f99d66d578b198e69cdc11a63f5af8501b7674fd Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:38:26 +0530 Subject: [PATCH 05/23] 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. (cherry picked from commit d05bd80b1ef48fb50fd68d3c9ae97e0e4aa119d6) --- 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 d718110216109eb89961a5c202e8852f5ea33717 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:38:42 +0530 Subject: [PATCH 06/23] 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. (cherry picked from commit 15d10bbaf1d5a53264d9ded5d2567e480d64cb7c) --- .../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 c0ac04301cc..648b98df0a8 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 d2fe4b623c8390a2398d3eafc96d49664a26eedf Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:39:03 +0530 Subject: [PATCH 07/23] 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. (cherry picked from commit 6f22551aae4652a1a3eb7909d4353e7f4574ebf1) --- .../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 1aefaefd514..62bdb23fd4c 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -534,23 +534,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 5aaefec7479418650eb58ca8e8cdc17c89afa9d3 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:52:23 +0530 Subject: [PATCH 08/23] 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. (cherry picked from commit d0cae2eb9c7e8a26175777fe30c750d9682a4db4) --- .../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 62bdb23fd4c..b3853c43218 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -494,7 +494,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 1adeb66bdc0e0b46df2e4df8461402c670604ab0 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:52:23 +0530 Subject: [PATCH 09/23] 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. (cherry picked from commit 53e09dfdd67eca10e552276dfd7933661a2aea93) --- 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 6b056ebb36382b616119520af881c65fea1cae56 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:52:23 +0530 Subject: [PATCH 10/23] test: alert when Required By falls back to today (cherry picked from commit 671c289303e0fe354e54a49edfcd7e867e3d2732) --- .../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 648b98df0a8..13646cb6b17 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 810b9ae28f818a130203ca82fe5439a05f8163fb Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 08:57:04 +0530 Subject: [PATCH 11/23] 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. (cherry picked from commit 5a78e2290a15847e83ae0ddf81ff3ecdeaba53b5) --- 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 b3853c43218..345d5cfb846 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -523,7 +523,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( @@ -532,8 +536,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 d6ee5436b8dd36815494d15ff5329b6252e8db3e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:00:12 +0530 Subject: [PATCH 12/23] 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. (cherry picked from commit 07445b367551f5c40d1989a58fdab2e3a65f63fe) --- 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 345d5cfb846..e40636b4afe 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -445,7 +445,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"), @@ -519,7 +519,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 6096e761b04da16e5f2212357814b0de04405e3f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:00:12 +0530 Subject: [PATCH 13/23] test: reject a supplier selection without items (cherry picked from commit d233fdf1988bf242aaf07ab17494f1cf5c6f2480) --- 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 13646cb6b17..676e30993c3 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 e71cef02b3317369ff438176cf683e9176ab4939 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:02:05 +0530 Subject: [PATCH 14/23] 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. (cherry picked from commit 3856eaa35e106279f7403ee4ad56c46f07362a26) --- 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 e40636b4afe..3948362d4f3 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -550,8 +550,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 06a753faf309cc237ead9fefd1a74ee9c701ecb0 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:11:37 +0530 Subject: [PATCH 15/23] 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. (cherry picked from commit 21c6d10ad3c99c44c612cc34b6f1f30208ecfd61) --- .../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 3948362d4f3..787ea536811 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -524,23 +524,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 ea770f6a8ebf7eac468814c8b3c049a88f7cfa17 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:11:37 +0530 Subject: [PATCH 16/23] 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. (cherry picked from commit 99d56cc850c4095cd8cbbfdc3a7fc6e1691053ba) --- 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 380ee3b01304ce9ca8addc2fa70e8add0e794508 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:11:37 +0530 Subject: [PATCH 17/23] test: reject the same Material Request item twice in one supplier selection (cherry picked from commit 8ffe5ba420380f34f180c4df4fe87bc9c98112ea) --- .../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 676e30993c3..933c74e5750 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 93331a1cf01e2407c6c943258221398d6f31a503 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:16:13 +0530 Subject: [PATCH 18/23] 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. (cherry picked from commit f0bb70539db2c484c543ccb614a0a45b11b7dfe6) --- 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 787ea536811..4835f9d30d6 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -518,7 +518,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")); @@ -552,6 +552,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 9b647bed5c77d9ef2d2aff8a8cb589498fe9845b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:19:49 +0530 Subject: [PATCH 19/23] 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. (cherry picked from commit e84bf44e5197a9004bc758786ae13be54c759cca) --- .../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 4835f9d30d6..998c3d48477 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -447,10 +447,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", @@ -510,6 +528,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 2c9db13041928c74edd8faa074cc9514d8ef9d16 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:21:16 +0530 Subject: [PATCH 20/23] 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. (cherry picked from commit 44fdf7bea96f796c0dda0e9629ffa79a228c2a5d) --- 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 998c3d48477..fa342a1a79f 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -469,6 +469,8 @@ frappe.ui.form.on("Material Request", { dialog.fields_dict.items.grid.refresh(); }, }, + { fieldtype: "Column Break" }, + { fieldtype: "Section Break" }, { fieldname: "items", fieldtype: "Table", From 187840b559f3615b9f37baa9e6fcd7f2ec54e76f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sat, 1 Aug 2026 09:23:09 +0530 Subject: [PATCH 21/23] 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. (cherry picked from commit 2e72846670dd251143a33811306b83ab869a431e) --- 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 fa342a1a79f..498542036e3 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -474,6 +474,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, From e98471d9c9dbf3c52e600e379f74f464dd1a6227 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 2 Aug 2026 12:03:47 +0530 Subject: [PATCH 22/23] fix: resolve version 16 backport conflicts --- .../material_request/material_request.js | 7 -- .../material_request/test_material_request.py | 102 ------------------ 2 files changed, 109 deletions(-) diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 498542036e3..9f7df80b9c4 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -411,12 +411,6 @@ frappe.ui.form.on("Material Request", { }, make_purchase_order: function (frm) { -<<<<<<< HEAD - frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.material_request.material_request.make_purchase_order", - frm: frm, - run_link_triggers: true, -======= frappe.call({ method: "erpnext.stock.doctype.material_request.mapper.get_item_default_suppliers", args: { @@ -440,7 +434,6 @@ frappe.ui.form.on("Material Request", { run_link_triggers: true, }); }, ->>>>>>> e8df7b4a90 (feat: select a supplier per item when creating Purchase Orders from Material Request) }); }, diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index 933c74e5750..d0edfa1bd4b 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -1189,107 +1189,6 @@ class TestMaterialRequest(ERPNextTestSuite): self.assertEqual(material_request.status, "Transferred") self.assertEqual(material_request.transfer_status, "Completed") -<<<<<<< HEAD -======= - def test_check_modified_date_detects_concurrent_modification(self): - """check_modified_date must raise when the in-memory doc is stale vs the DB modified - timestamp. Covers the converted get_value + get_datetime comparison that replaced the - raw MariaDB-only TIMEDIFF (which errors on Postgres); update_status() runs this guard.""" - from frappe.utils import add_to_date, get_datetime - - mr = make_material_request(qty=10) - - fresh = frappe.get_doc("Material Request", mr.name) - # modified matches the DB row -> guard passes. - fresh.check_modified_date() - - # Stale in-memory modified -> concurrent-modification guard must fire. - fresh.modified = add_to_date(get_datetime(fresh.modified), seconds=-120) - with self.assertRaises(frappe.ValidationError) as cm: - fresh.check_modified_date() - self.assertIn("has been modified", str(cm.exception)) - - def test_validate_qty_against_so_blocks_over_request(self): - """validate_qty_against_so must block requesting more than the Sales Order qty, net of - already-indented submitted MRs. Covers the converted Sales Order Item and Material Request - Item SUM queries. (The guard is currently not wired into validate(), so call it directly.)""" - from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order - - item_code = "_Test Item" - so = make_sales_order(item_code=item_code, qty=10) # submitted -> SO Item stock_qty 10 - - def _mr_against_so(qty): - mr = frappe.new_doc("Material Request") - mr.material_request_type = "Purchase" - mr.company = "_Test Company" - mr.append( - "items", - { - "item_code": item_code, - "qty": qty, - "uom": "_Test UOM", - "conversion_factor": 1, - "schedule_date": today(), - "warehouse": "_Test Warehouse - _TC", - "sales_order": so.name, - }, - ) - return mr - - # An already-submitted MR consuming 6 of the SO's 10. - mr1 = _mr_against_so(6) - mr1.insert() - mr1.submit() - - # A new request for 5 more -> already_indented 6 + 5 = 11 > 10 -> must throw. - over = _mr_against_so(5) - over.insert() - with self.assertRaises(frappe.ValidationError) as cm: - over.validate_qty_against_so() - self.assertIn("maximum", str(cm.exception)) - - # Exactly within the remaining 4 -> 6 + 4 = 10, not greater -> no throw. - over.items[0].qty = 4 - over.validate_qty_against_so() - - def test_get_material_requests_based_on_supplier(self): - """The supplier-based Material Request picker must run on every engine. - - It deduplicated requests with SELECT DISTINCT while ordering by an item - column that is not in the select list; PostgreSQL rejects that, so the - picker has to group and order by an aggregate instead. - """ - from erpnext.stock.doctype.material_request.material_request import ( - get_material_requests_based_on_supplier, - ) - - item = create_item("_Test MR Default Supplier Item") - item.set("item_defaults", []) - item.append( - "item_defaults", - { - "company": "_Test Company", - "default_warehouse": "_Test Warehouse - _TC", - "default_supplier": "_Test Supplier", - }, - ) - item.save() - - mr1 = make_material_request(item_code=item.name, qty=5) - mr2 = make_material_request(item_code=item.name, qty=7) - - result = get_material_requests_based_on_supplier( - doctype="Material Request", - txt="", - searchfield="name", - start=0, - page_len=20, - filters={"supplier": "_Test Supplier", "company": "_Test Company"}, - ) - returned = {row["name"] for row in result} - 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 @@ -1428,7 +1327,6 @@ def make_material_request_for_items(item_codes, **args): return mr ->>>>>>> 65be201ed6 (test: supplier selection when creating Purchase Orders from Material Request) def get_in_transit_warehouse(company): if not frappe.db.exists("Warehouse Type", "Transit"): From e5f8d0c84ba122ddbade75da97d0c4cca553a730 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 2 Aug 2026 12:25:22 +0530 Subject: [PATCH 23/23] fix: apply the supplier selection to this branch's own mapper module The backport carried develop's mapper module across whole, while version 16 keeps its mappers in material_request.py. That left two copies of the mapping layer: the dialog and the new tests reached for the imported module, and make_purchase_order, which the rest of the branch and the older tests use, never learned to set the supplier - so test_make_purchase_order_sets_supplier failed. The feature now sits in material_request.py alongside the mappers it extends, and the imported module is dropped. --- .../stock/doctype/material_request/mapper.py | 521 ------------------ .../material_request/material_request.js | 6 +- .../material_request/material_request.py | 152 ++++- .../material_request/test_material_request.py | 8 +- 4 files changed, 155 insertions(+), 532 deletions(-) delete mode 100644 erpnext/stock/doctype/material_request/mapper.py diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py deleted file mode 100644 index 26569f58cec..00000000000 --- a/erpnext/stock/doctype/material_request/mapper.py +++ /dev/null @@ -1,521 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - -import json - -import frappe -from frappe import _ -from frappe.model.document import Document -from frappe.model.mapper import get_mapped_doc -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 -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, -) - - -def set_missing_values(source, target_doc): - if target_doc.doctype == "Purchase Order" and getdate(target_doc.schedule_date) < getdate(nowdate()): - target_doc.schedule_date = None - target_doc.run_method("set_missing_values") - 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 - - qty = obj.ordered_qty or obj.received_qty - target.qty = flt(flt(obj.stock_qty) - flt(qty)) / target.conversion_factor - target.stock_qty = target.qty * target.conversion_factor - if getdate(target.schedule_date) < getdate(nowdate()): - target.schedule_date = None - - if target.fg_item: - target.fg_item_qty = obj.stock_qty - if sc_bom := get_subcontracting_boms_for_finished_goods(target.fg_item): - target.item_code = sc_bom.service_item - target.uom = sc_bom.service_item_uom - target.conversion_factor = ( - frappe.db.get_value( - "UOM Conversion Detail", - {"parent": sc_bom.service_item, "uom": sc_bom.service_item_uom}, - "conversion_factor", - ) - or 1 - ) - target.qty = target.fg_item_qty * sc_bom.conversion_factor - target.stock_qty = target.qty * target.conversion_factor - - -@frappe.whitelist() -def make_purchase_order( - source_name: str, target_doc: str | dict | Document | None = None, args: dict | str | None = None -): - if args is None: - args = frappe.flags.args or {} - args = frappe.parse_json(args) - - is_subcontracted = ( - 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 - - qty = d.ordered_qty or d.received_qty - - return qty < d.stock_qty and child_filter - - def generate_field_map(): - field_map = [ - ["name", "material_request_item"], - ["parent", "material_request"], - ["sales_order", "sales_order"], - ["sales_order_item", "sales_order_item"], - ["wip_composite_asset", "wip_composite_asset"], - ] - - if is_subcontracted: - field_map.extend([["item_code", "fg_item"], ["qty", "fg_item_qty"]]) - else: - field_map.extend([["uom", "stock_uom"], ["uom", "uom"]]) - - return field_map - - doclist = get_mapped_doc( - "Material Request", - source_name, - { - "Material Request": { - "doctype": "Purchase Order", - "validation": { - "docstatus": ["=", 1], - "material_request_type": ["in", ["Purchase", "Subcontracting"]], - }, - }, - "Material Request Item": { - "doctype": "Purchase Order Item", - "field_map": generate_field_map(), - "field_no_map": ["item_code", "item_name", "qty"] if is_subcontracted else [], - "postprocess": update_requested_item, - "condition": select_item, - }, - }, - target_doc, - postprocess, - ) - - doclist.set_onload("load_after_mapping", False) - return doclist - - -@frappe.whitelist() -def make_request_for_quotation(source_name: str, target_doc: str | dict | Document | None = None): - doclist = get_mapped_doc( - "Material Request", - source_name, - { - "Material Request": { - "doctype": "Request for Quotation", - "validation": {"docstatus": ["=", 1], "material_request_type": ["=", "Purchase"]}, - }, - "Material Request Item": { - "doctype": "Request for Quotation Item", - "field_map": [ - ["name", "material_request_item"], - ["parent", "material_request"], - ["project", "project_name"], - ["cost_center", "cost_center"], - ], - }, - }, - target_doc, - ) - - 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, - "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), - } - ) - - 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) - 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) - } - - 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)) - - 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( - item_link, frappe.bold(f"{pending_qty} {pending.uom or ''}".strip()) - ) - ) - - 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, - args={ - "supplier": supplier, - "filtered_children": list(requested_qty), - "requested_qty": requested_qty, - }, - ) - for item in purchase_order.items: - 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", - ) - - 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 - - -@frappe.whitelist() -def get_items_based_on_default_supplier(supplier: str): - supplier_items = [ - d.parent - for d in frappe.db.get_all( - "Item Default", {"default_supplier": supplier, "parenttype": "Item"}, "parent" - ) - ] - - return supplier_items - - -@frappe.whitelist() -def make_purchase_order_based_on_supplier( - source_name: str, target_doc: str | dict | Document | None = None, args: dict | None = None -): - mr = source_name - - supplier_items = get_items_based_on_default_supplier(args.get("supplier")) - - def postprocess(source, target_doc): - target_doc.supplier = args.get("supplier") - if getdate(target_doc.schedule_date) < getdate(nowdate()): - target_doc.schedule_date = None - target_doc.set( - "items", - [d for d in target_doc.get("items") if d.get("item_code") in supplier_items and d.get("qty") > 0], - ) - - set_missing_values(source, target_doc) - - target_doc = get_mapped_doc( - "Material Request", - mr, - { - "Material Request": { - "doctype": "Purchase Order", - }, - "Material Request Item": { - "doctype": "Purchase Order Item", - "field_map": [ - ["name", "material_request_item"], - ["parent", "material_request"], - ["uom", "stock_uom"], - ["uom", "uom"], - ], - "postprocess": update_item, - "condition": lambda doc: doc.ordered_qty < doc.qty, - }, - }, - target_doc, - postprocess, - ) - - return target_doc - - -@frappe.whitelist() -def make_supplier_quotation(source_name: str, target_doc: str | dict | Document | None = None): - def postprocess(source, target_doc): - set_missing_values(source, target_doc) - - doclist = get_mapped_doc( - "Material Request", - source_name, - { - "Material Request": { - "doctype": "Supplier Quotation", - "validation": {"docstatus": ["=", 1], "material_request_type": ["=", "Purchase"]}, - }, - "Material Request Item": { - "doctype": "Supplier Quotation Item", - "field_map": { - "name": "material_request_item", - "parent": "material_request", - "sales_order": "sales_order", - }, - }, - }, - target_doc, - postprocess, - ) - - doclist.set_onload("load_after_mapping", False) - return doclist - - -@frappe.whitelist() -def make_stock_entry(source_name: str, target_doc: str | dict | Document | None = None): - def update_item(obj, target, source_parent): - qty = ( - flt(flt(obj.stock_qty) - flt(obj.ordered_qty)) / target.conversion_factor - if flt(obj.stock_qty) > flt(obj.ordered_qty) - else 0 - ) - target.qty = qty - target.transfer_qty = qty * obj.conversion_factor - target.conversion_factor = obj.conversion_factor - - if ( - source_parent.material_request_type == "Material Transfer" - or source_parent.material_request_type == "Customer Provided" - ): - target.t_warehouse = obj.warehouse - else: - target.s_warehouse = obj.warehouse - - if source_parent.material_request_type == "Customer Provided": - target.allow_zero_valuation_rate = 1 - - if source_parent.material_request_type == "Material Transfer": - target.s_warehouse = obj.from_warehouse - - def set_missing_values(source, target): - target.purpose = source.material_request_type - target.from_warehouse = source.set_from_warehouse - target.to_warehouse = source.set_warehouse - if source.material_request_type == "Material Issue": - target.from_warehouse = source.set_warehouse - target.to_warehouse = None - - if source.job_card: - target.purpose = "Material Transfer for Manufacture" - - if source.work_order: - target.purpose = "Material Transfer for Manufacture" - - if source.material_request_type == "Customer Provided": - target.purpose = "Material Receipt" - - target.set_transfer_qty() - target.set_actual_qty() - target.calculate_rate_and_amount(raise_error_if_no_rate=False) - target.stock_entry_type = target.purpose - - if source.job_card: - job_card_details = frappe.get_all( - "Job Card", filters={"name": source.job_card}, fields=["bom_no", "for_quantity"] - ) - - if job_card_details and job_card_details[0]: - target.bom_no = job_card_details[0].bom_no - target.fg_completed_qty = job_card_details[0].for_quantity - target.from_bom = 1 - - if source.work_order: - work_order_details = frappe.db.get_value( - "Work Order", source.work_order, ["bom_no", "use_multi_level_bom"], as_dict=True - ) - - if work_order_details: - target.bom_no = work_order_details.bom_no - target.use_multi_level_bom = work_order_details.use_multi_level_bom - target.from_bom = 1 - # not fg-qty-driven, mirrors the Pick List -> Stock Entry transfer for this Work Order - target.fg_completed_qty = 0 - - doclist = get_mapped_doc( - "Material Request", - source_name, - { - "Material Request": { - "doctype": "Stock Entry", - "validation": { - "docstatus": ["=", 1], - "material_request_type": [ - "in", - ["Material Transfer", "Material Issue", "Customer Provided"], - ], - }, - }, - "Material Request Item": { - "doctype": "Stock Entry Detail", - "field_map": { - "name": "material_request_item", - "parent": "material_request", - "uom": "stock_uom", - "job_card_item": "job_card_item", - }, - "field_no_map": ["expense_account"], - "postprocess": update_item, - "condition": lambda doc: ( - flt(doc.ordered_qty, doc.precision("ordered_qty")) - < flt(doc.stock_qty, doc.precision("ordered_qty")) - ), - }, - }, - target_doc, - set_missing_values, - ) - - return doclist - - -@frappe.whitelist() -def create_pick_list(source_name: str, target_doc: str | dict | Document | None = None): - def update_item(obj, target, source_parent): - qty = flt((obj.stock_qty - obj.picked_qty) / target.conversion_factor, obj.precision("qty")) - target.qty = qty - target.stock_qty = qty * obj.conversion_factor - target.conversion_factor = obj.conversion_factor - - doc = get_mapped_doc( - "Material Request", - source_name, - { - "Material Request": { - "doctype": "Pick List", - "field_map": {"material_request_type": "purpose"}, - "validation": {"docstatus": ["=", 1]}, - }, - "Material Request Item": { - "doctype": "Pick List Item", - "field_map": { - "name": "material_request_item", - "stock_qty": "stock_qty", - "from_warehouse": "warehouse", - }, - "postprocess": update_item, - "condition": lambda doc: ( - flt(doc.picked_qty, doc.precision("picked_qty")) - < flt(doc.stock_qty, doc.precision("stock_qty")) - ), - }, - }, - target_doc, - ) - - doc.set_item_locations() - - return doc - - -@frappe.whitelist() -def make_in_transit_stock_entry(source_name: str, in_transit_warehouse: str): - ste_doc = make_stock_entry(source_name) - ste_doc.add_to_transit = 1 - ste_doc.to_warehouse = in_transit_warehouse - - for row in ste_doc.items: - row.t_warehouse = in_transit_warehouse - - return ste_doc diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 9f7df80b9c4..4ff2c2c27af 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -412,7 +412,7 @@ frappe.ui.form.on("Material Request", { make_purchase_order: function (frm) { frappe.call({ - method: "erpnext.stock.doctype.material_request.mapper.get_item_default_suppliers", + method: "erpnext.stock.doctype.material_request.material_request.get_item_default_suppliers", args: { source_name: frm.doc.name, filtered_children: (frm.get_selected() || {}).items || [], @@ -428,7 +428,7 @@ frappe.ui.form.on("Material Request", { } frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.material_request.mapper.make_purchase_order", + method: "erpnext.stock.doctype.material_request.material_request.make_purchase_order", frm: frm, args: { supplier: items.length ? items[0].supplier : null }, run_link_triggers: true, @@ -572,7 +572,7 @@ frappe.ui.form.on("Material Request", { } frappe.call({ - method: "erpnext.stock.doctype.material_request.mapper.make_purchase_orders_by_supplier", + method: "erpnext.stock.doctype.material_request.material_request.make_purchase_orders_by_supplier", args: { source_name: frm.doc.name, item_suppliers: item_suppliers }, freeze: true, callback: function (r) { diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index daebeaa4e1b..431bdd742a0 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -13,12 +13,24 @@ from frappe import _, msgprint from frappe.model.mapper import get_mapped_doc from frappe.query_builder import Order from frappe.query_builder.functions import Sum -from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, new_line_sep, nowdate +from frappe.utils import ( + cint, + comma_and, + cstr, + flt, + get_link_to_form, + getdate, + new_line_sep, + nowdate, +) from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_items from erpnext.controllers.buying_controller import BuyingController from erpnext.manufacturing.doctype.work_order.work_order import get_item_details -from erpnext.stock.get_item_details import get_price_list_rate_for +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, get_price_list_rate_for from erpnext.stock.stock_balance import get_indented_qty, update_bin_qty from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import ( get_subcontracting_boms_for_finished_goods, @@ -485,6 +497,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 @@ -538,7 +560,7 @@ def update_status(name, status): @frappe.whitelist() def make_purchase_order(source_name, target_doc=None, args=None): if args is None: - args = {} + args = frappe.flags.args or {} if isinstance(args, str): args = json.loads(args) @@ -546,10 +568,19 @@ def make_purchase_order(source_name, target_doc=None, args=None): 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 @@ -589,7 +620,7 @@ def make_purchase_order(source_name, target_doc=None, args=None): "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, }, }, @@ -601,6 +632,119 @@ def make_purchase_order(source_name, target_doc=None, args=None): 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, + "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), + } + ) + + 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) + 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) + } + + 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)) + + 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( + item_link, frappe.bold(f"{pending_qty} {pending.uom or ''}".strip()) + ) + ) + + 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, + args={ + "supplier": supplier, + "filtered_children": list(requested_qty), + "requested_qty": requested_qty, + }, + ) + for item in purchase_order.items: + 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", + ) + + 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 + + @frappe.whitelist() def make_request_for_quotation(source_name, target_doc=None): doclist = get_mapped_doc( diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index d0edfa1bd4b..c7e327a5646 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -1190,7 +1190,7 @@ class TestMaterialRequest(ERPNextTestSuite): self.assertEqual(material_request.transfer_status, "Completed") def test_get_item_default_suppliers(self): - from erpnext.stock.doctype.material_request.mapper import get_item_default_suppliers + from erpnext.stock.doctype.material_request.material_request 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 @@ -1210,7 +1210,7 @@ class TestMaterialRequest(ERPNextTestSuite): 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 + from erpnext.stock.doctype.material_request.material_request 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) @@ -1240,7 +1240,7 @@ class TestMaterialRequest(ERPNextTestSuite): 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 + from erpnext.stock.doctype.material_request.material_request 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)) @@ -1264,7 +1264,7 @@ class TestMaterialRequest(ERPNextTestSuite): 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 erpnext.stock.doctype.material_request.material_request import make_purchase_orders_by_supplier mr = make_material_request_for_items(["_Test Item"]) row = {