From e8df7b4a901dc3255ad28bcb183656b348cfb14d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 31 Jul 2026 22:22:49 +0530 Subject: [PATCH] feat: select a supplier per item when creating Purchase Orders from Material Request Creating a Purchase Order from a Material Request mapped every pending item into a single order, leaving the buyer to split it by hand whenever the items came from different vendors. The Create action now reads the default supplier of each pending item (item, item group, then brand defaults). When the items resolve to more than one distinct supplier - including the case where only some of them have a default - a dialog lists the items with their default supplier prefilled and editable. Submitting it groups the items by the chosen supplier and creates one draft Purchase Order per group. When every item resolves to the same supplier the order is mapped straight away with that supplier set, and when none of them has a default supplier the previous behaviour is unchanged. --- .../stock/doctype/material_request/mapper.py | 72 ++++++++++- .../material_request/material_request.js | 122 +++++++++++++++++- 2 files changed, 189 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py index 14def8afcd7..7d192b30b44 100644 --- a/erpnext/stock/doctype/material_request/mapper.py +++ b/erpnext/stock/doctype/material_request/mapper.py @@ -9,6 +9,10 @@ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.utils import cint, flt, getdate, nowdate +from erpnext.setup.doctype.brand.brand import get_brand_defaults +from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults +from erpnext.stock.doctype.item.item import get_item_defaults +from erpnext.stock.get_item_details import get_default_supplier from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import ( get_subcontracting_boms_for_finished_goods, ) @@ -52,7 +56,7 @@ def make_purchase_order( source_name: str, target_doc: str | dict | Document | None = None, args: dict | str | None = None ): if args is None: - args = {} + args = frappe.flags.args or {} args = frappe.parse_json(args) is_subcontracted = ( @@ -61,6 +65,8 @@ def make_purchase_order( def postprocess(source, target_doc): target_doc.is_subcontracted = is_subcontracted + if args.get("supplier"): + target_doc.supplier = args.get("supplier") set_missing_values(source, target_doc) def select_item(d): @@ -140,6 +146,70 @@ def make_request_for_quotation(source_name: str, target_doc: str | dict | Docume return doclist +def get_default_supplier_for_item(item_code: str, company: str) -> str | None: + return get_default_supplier( + frappe._dict(), + get_item_defaults(item_code, company), + get_item_group_defaults(item_code, company), + get_brand_defaults(item_code, company), + ) + + +@frappe.whitelist() +def get_item_default_suppliers(source_name: str, filtered_children: str | list | None = None) -> list[dict]: + """Pending items of the Material Request with their default supplier.""" + filtered_children = frappe.parse_json(filtered_children) if filtered_children else [] + + material_request = frappe.get_doc("Material Request", source_name) + material_request.check_permission("read") + + items = [] + for item in material_request.items: + if filtered_children and item.name not in filtered_children: + continue + + ordered_qty = flt(item.ordered_qty) or flt(item.received_qty) + if ordered_qty >= flt(item.stock_qty): + continue + + items.append( + { + "material_request_item": item.name, + "item_code": item.item_code, + "item_name": item.item_name, + "qty": (flt(item.stock_qty) - ordered_qty) / (flt(item.conversion_factor) or 1), + "uom": item.uom, + "supplier": get_default_supplier_for_item(item.item_code, material_request.company), + } + ) + + return items + + +@frappe.whitelist(methods=["POST"]) +def make_purchase_orders_by_supplier(source_name: str, item_suppliers: str | list) -> list[str]: + """Create one draft Purchase Order per supplier for the given Material Request items.""" + item_suppliers = frappe.parse_json(item_suppliers) + + items_by_supplier = {} + for row in item_suppliers: + row = frappe._dict(row) + if not row.supplier: + frappe.throw(_("Select a Supplier for Item {0}").format(frappe.bold(row.item_code))) + + items_by_supplier.setdefault(row.supplier, []).append(row.material_request_item) + + purchase_orders = [] + for supplier, material_request_items in items_by_supplier.items(): + purchase_order = make_purchase_order( + source_name, args={"supplier": supplier, "filtered_children": material_request_items} + ) + purchase_order.insert() + purchase_orders.append(purchase_order.name) + + return purchase_orders + + @frappe.whitelist() def get_items_based_on_default_supplier(supplier: str): supplier_items = [ diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index b5a8c0560cd..a69faf7ec31 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -412,13 +412,127 @@ frappe.ui.form.on("Material Request", { }, make_purchase_order: function (frm) { - frappe.model.open_mapped_doc({ - method: "erpnext.stock.doctype.material_request.mapper.make_purchase_order", - frm: frm, - run_link_triggers: true, + frappe.call({ + method: "erpnext.stock.doctype.material_request.mapper.get_item_default_suppliers", + args: { + source_name: frm.doc.name, + filtered_children: (frm.get_selected() || {}).items || [], + }, + freeze: true, + callback: function (r) { + const items = r.message || []; + const suppliers = new Set(items.map((item) => item.supplier || "")); + + if (suppliers.size > 1) { + frm.events.select_suppliers_for_items(frm, items); + return; + } + + frappe.model.open_mapped_doc({ + method: "erpnext.stock.doctype.material_request.mapper.make_purchase_order", + frm: frm, + args: { supplier: items.length ? items[0].supplier : null }, + run_link_triggers: true, + }); + }, }); }, + select_suppliers_for_items: function (frm, items) { + const dialog = new frappe.ui.Dialog({ + title: __("Select Supplier for Items"), + size: "large", + fields: [ + { + fieldname: "items", + fieldtype: "Table", + cannot_add_rows: true, + cannot_delete_rows: true, + in_place_edit: true, + data: items, + get_data: () => items, + description: __("A separate Purchase Order is created for each Supplier."), + fields: [ + { + fieldtype: "Data", + fieldname: "material_request_item", + hidden: 1, + }, + { + fieldtype: "Link", + fieldname: "item_code", + options: "Item", + label: __("Item Code"), + read_only: 1, + in_list_view: 1, + columns: 3, + }, + { + fieldtype: "Data", + fieldname: "item_name", + label: __("Item Name"), + read_only: 1, + in_list_view: 1, + columns: 2, + }, + { + fieldtype: "Float", + fieldname: "qty", + label: __("Quantity"), + read_only: 1, + in_list_view: 1, + columns: 2, + }, + { + fieldtype: "Link", + fieldname: "supplier", + options: "Supplier", + label: __("Supplier"), + reqd: 1, + in_list_view: 1, + columns: 3, + }, + ], + }, + ], + primary_action_label: __("Create"), + primary_action: function (values) { + const rows = values.items || []; + const missing = rows.find((row) => !row.supplier); + if (missing) { + frappe.throw(__("Select a Supplier for Item {0}", [missing.item_code])); + } + + frappe.call({ + method: "erpnext.stock.doctype.material_request.mapper.make_purchase_orders_by_supplier", + args: { source_name: frm.doc.name, item_suppliers: rows }, + freeze: true, + callback: function (r) { + if (r.exc) return; + + dialog.hide(); + + const purchase_orders = r.message || []; + if (purchase_orders.length === 1) { + frappe.set_route("Form", "Purchase Order", purchase_orders[0]); + return; + } + + frappe.msgprint({ + title: __("Purchase Orders Created"), + indicator: "green", + message: purchase_orders + .map((name) => frappe.utils.get_form_link("Purchase Order", name, true)) + .join(", "), + }); + }, + }); + }, + }); + + dialog.show(); + }, + make_request_for_quotation: function (frm) { frappe.model.open_mapped_doc({ method: "erpnext.stock.doctype.material_request.mapper.make_request_for_quotation",