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 e8df7b4a90)

# Conflicts:
#	erpnext/stock/doctype/material_request/mapper.py
#	erpnext/stock/doctype/material_request/material_request.js
This commit is contained in:
Mihir Kandoi
2026-07-31 22:22:49 +05:30
committed by Mergify
parent 1fbccd9823
commit 95f7810948
2 changed files with 576 additions and 0 deletions

View File

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

View File

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