mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-08 04:01:46 +00:00
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:
455
erpnext/stock/doctype/material_request/mapper.py
Normal file
455
erpnext/stock/doctype/material_request/mapper.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user