diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 7dab81beba1..4ff2c2c27af 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -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), + `${pending_qty}`, + ]) + ); + } + + 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", 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 51ad01517a4..7d044b929f4 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 @@ -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"):