Merge pull request #57698 from frappe/mergify/bp/version-16-hotfix/pr-57676

feat: select a supplier per item when creating Purchase Orders from a Material Request (backport #57676)
This commit is contained in:
Mihir Kandoi
2026-08-02 12:35:56 +05:30
committed by GitHub
3 changed files with 465 additions and 9 deletions

View File

@@ -411,13 +411,187 @@ frappe.ui.form.on("Material Request", {
},
make_purchase_order: function (frm) {
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.material_request.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.material_request.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 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();
},
},
{ fieldtype: "Column Break" },
{ fieldtype: "Section Break" },
{
fieldname: "items",
fieldtype: "Table",
label: __("Items"),
cannot_add_rows: true,
cannot_delete_rows: true,
in_place_edit: true,
data: rows,
get_data: () => rows,
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: "pending_qty",
hidden: 1,
},
{
fieldtype: "Float",
fieldname: "qty",
label: __("Quantity"),
reqd: 1,
in_list_view: 1,
columns: 1,
},
{
fieldtype: "Link",
fieldname: "uom",
options: "UOM",
label: __("UOM"),
read_only: 1,
in_list_view: 1,
columns: 1,
},
{
fieldtype: "Link",
fieldname: "supplier",
options: "Supplier",
label: __("Supplier"),
get_query: supplier_query,
reqd: 1,
in_list_view: 1,
columns: 3,
},
],
},
],
primary_action_label: __("Create"),
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"));
}
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}", [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}", [
item_link(invalid_qty),
`<b>${pending_qty}</b>`,
])
);
}
if (!(await erpnext.utils.confirm_if_drafts_exist(frm.doc, "Purchase Order"))) {
return;
}
frappe.call({
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) {
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]);
}
},
});
},
});
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",

View File

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

View File

@@ -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
@@ -1211,6 +1211,144 @@ class TestMaterialRequest(ERPNextTestSuite):
self.assertEqual(material_request.status, "Transferred")
self.assertEqual(material_request.transfer_status, "Completed")
def test_get_item_default_suppliers(self):
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
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]["pending_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.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)
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,
"qty": qty,
"supplier": supplier,
}
for item, supplier, qty in zip(mr.items, suppliers, [10, 10, 4], 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:])
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.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))
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()))
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.material_request 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",
}
for invalid in [{"supplier": None}, {"qty": 0}, {"qty": -5}, {"qty": 11}]:
self.assertRaises(
frappe.ValidationError, make_purchase_orders_by_supplier, mr.name, [row | invalid]
)
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)
item.set("item_defaults", [])
item.append(
"item_defaults",
{
"company": "_Test Company",
"default_warehouse": "_Test Warehouse - _TC",
"default_supplier": supplier,
},
)
item.save()
return item.name
def make_material_request_for_items(item_codes, **args):
args = frappe._dict(args)
mr = frappe.new_doc("Material Request")
mr.material_request_type = args.material_request_type or "Purchase"
mr.company = args.company or "_Test Company"
mr.schedule_date = today()
for item_code in item_codes:
mr.append(
"items",
{
"item_code": item_code,
"qty": args.qty or 10,
"schedule_date": today(),
"warehouse": args.warehouse or "_Test Warehouse - _TC",
},
)
mr.insert()
mr.submit()
return mr
def get_in_transit_warehouse(company):
if not frappe.db.exists("Warehouse Type", "Transit"):