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.
This commit is contained in:
Mihir Kandoi
2026-07-31 22:22:49 +05:30
parent 7febc28ed6
commit e8df7b4a90
2 changed files with 189 additions and 5 deletions

View File

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

View File

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