diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py new file mode 100644 index 00000000000..7d192b30b44 --- /dev/null +++ b/erpnext/stock/doctype/material_request/mapper.py @@ -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 diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 7dab81beba1..549ee14f0c8 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -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",