mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-04 00:50:18 +00:00
Compare commits
3 Commits
develop
...
ask-for-bo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e8b96d816 | ||
|
|
13e957bb7c | ||
|
|
ad0d3e1fbf |
@@ -128,53 +128,150 @@ frappe.query_reports["Material Requirements Planning Report"] = {
|
||||
|
||||
onload(report) {
|
||||
report.page.add_inner_button(__("Make Purchase / Work Order"), () => {
|
||||
let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
|
||||
let selected_rows = indexes.map((i) => frappe.query_report.data[i]);
|
||||
const indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
|
||||
const selected_rows = indexes
|
||||
.map((i) => frappe.query_report.data[i])
|
||||
.filter((row) => row && row.item_code);
|
||||
|
||||
if (!selected_rows.length) {
|
||||
frappe.throw(__("Please select a row to create a Reposting Entry"));
|
||||
} else {
|
||||
let show_in_bucket_view = frappe.query_report.get_filter_value("show_in_bucket_view");
|
||||
if (show_in_bucket_view) {
|
||||
frappe.throw(__("Please uncheck 'Show in Bucket View' to create Orders"));
|
||||
}
|
||||
|
||||
frappe.prompt(
|
||||
[
|
||||
{
|
||||
fieldname: "use_default_warehouse",
|
||||
label: __("Use Default Warehouse"),
|
||||
fieldtype: "Check",
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "warehouse",
|
||||
label: __("Warehouse"),
|
||||
fieldtype: "Link",
|
||||
options: "Warehouse",
|
||||
depends_on: "eval:!doc.use_default_warehouse",
|
||||
mandatory_depends_on: "eval:!doc.use_default_warehouse",
|
||||
},
|
||||
],
|
||||
(prompt_data) => {
|
||||
frappe.call({
|
||||
method: "erpnext.manufacturing.report.material_requirements_planning_report.material_requirements_planning_report.make_order",
|
||||
freeze: true,
|
||||
args: {
|
||||
selected_rows: selected_rows,
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
warehouse: !prompt_data.use_default_warehouse ? prompt_data.warehouse : null,
|
||||
mps: frappe.query_report.get_filter_value("mps"),
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frappe.set_route("List", r.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (frappe.query_report.get_filter_value("show_in_bucket_view")) {
|
||||
frappe.throw(__("Please uncheck 'Show in Bucket View' to create Orders"));
|
||||
}
|
||||
|
||||
prompt_and_make_order(selected_rows);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function items_missing_bom(selected_rows) {
|
||||
const seen = new Set();
|
||||
const items = [];
|
||||
for (const row of selected_rows) {
|
||||
if (row.type_of_material !== "Manufacture" || row.bom_no || seen.has(row.item_code)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(row.item_code);
|
||||
items.push({ item_code: row.item_code, item_name: row.item_name });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function apply_selected_boms(selected_rows, bom_rows) {
|
||||
const bom_by_item = {};
|
||||
for (const row of bom_rows || []) {
|
||||
if (row.item_code && row.bom_no) {
|
||||
bom_by_item[row.item_code] = row.bom_no;
|
||||
}
|
||||
}
|
||||
for (const row of selected_rows) {
|
||||
if (!row.bom_no && bom_by_item[row.item_code]) {
|
||||
row.bom_no = bom_by_item[row.item_code];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prompt_and_make_order(selected_rows) {
|
||||
const missing_bom = items_missing_bom(selected_rows);
|
||||
const fields = [
|
||||
{
|
||||
fieldname: "use_default_warehouse",
|
||||
label: __("Use Default Warehouse"),
|
||||
fieldtype: "Check",
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "warehouse",
|
||||
label: __("Warehouse"),
|
||||
fieldtype: "Link",
|
||||
options: "Warehouse",
|
||||
depends_on: "eval:!doc.use_default_warehouse",
|
||||
mandatory_depends_on: "eval:!doc.use_default_warehouse",
|
||||
},
|
||||
];
|
||||
|
||||
if (missing_bom.length) {
|
||||
fields.push({
|
||||
label: __("Select BOM"),
|
||||
fieldtype: "Table",
|
||||
fieldname: "boms",
|
||||
reqd: 1,
|
||||
cannot_add_rows: true,
|
||||
cannot_delete_rows: true,
|
||||
in_place_edit: true,
|
||||
description: __("These items have no default BOM. Select one to create Work Orders."),
|
||||
data: missing_bom,
|
||||
get_data: () => missing_bom,
|
||||
fields: [
|
||||
{
|
||||
fieldtype: "Link",
|
||||
fieldname: "item_code",
|
||||
options: "Item",
|
||||
label: __("Item Code"),
|
||||
in_list_view: 1,
|
||||
read_only: 1,
|
||||
},
|
||||
{
|
||||
fieldtype: "Data",
|
||||
fieldname: "item_name",
|
||||
label: __("Item Name"),
|
||||
in_list_view: 1,
|
||||
read_only: 1,
|
||||
},
|
||||
{
|
||||
fieldtype: "Link",
|
||||
fieldname: "bom_no",
|
||||
options: "BOM",
|
||||
reqd: 1,
|
||||
label: __("BOM"),
|
||||
in_list_view: 1,
|
||||
get_query: (doc) => ({
|
||||
query: "erpnext.controllers.queries.bom",
|
||||
filters: { item: doc.item_code },
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Make Purchase / Work Order"),
|
||||
fields: fields,
|
||||
primary_action_label: __("Create"),
|
||||
primary_action(values) {
|
||||
if (missing_bom.length) {
|
||||
const bom_rows = dialog.fields_dict.boms.grid.get_data();
|
||||
const without_bom = bom_rows.filter((row) => !row.bom_no);
|
||||
if (without_bom.length) {
|
||||
frappe.msgprint(
|
||||
__("Please select a BOM for {0}", [
|
||||
without_bom.map((row) => row.item_code).join(", "),
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
apply_selected_boms(selected_rows, bom_rows);
|
||||
}
|
||||
|
||||
dialog.hide();
|
||||
frappe.call({
|
||||
method: "erpnext.manufacturing.report.material_requirements_planning_report.material_requirements_planning_report.make_order",
|
||||
freeze: true,
|
||||
args: {
|
||||
selected_rows: selected_rows,
|
||||
company: frappe.query_report.get_filter_value("company"),
|
||||
warehouse: values.use_default_warehouse ? null : values.warehouse,
|
||||
mps: frappe.query_report.get_filter_value("mps"),
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frappe.set_route("List", r.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
@@ -450,7 +450,7 @@ class MaterialRequirementsPlanningReport:
|
||||
if row.get("is_adhoc"):
|
||||
row.planned_qty += row.adhoc_qty
|
||||
|
||||
for field in ["min_order_qty", "purchase_uom", "safety_stock"]:
|
||||
for field in ("min_order_qty", "purchase_uom", "safety_stock", "default_supplier"):
|
||||
if rm_details.get(field):
|
||||
row[field] = rm_details.get(field)
|
||||
|
||||
@@ -460,15 +460,13 @@ class MaterialRequirementsPlanningReport:
|
||||
|
||||
i += 1
|
||||
row.capacity = 0
|
||||
row.type_of_material = get_type_of_material(rm_details.get("is_purchase_item"), row.bom_no)
|
||||
if rm_details.raw_materials:
|
||||
row.capacity = get_item_capacity(row.item_code, self.filters.bucket_size)
|
||||
row.type_of_material = "Manufacture"
|
||||
if row.lead_time and row.required_qty:
|
||||
row.lead_time = math.ceil(row.required_qty / row.lead_time)
|
||||
elif not row.required_qty:
|
||||
row.lead_time = 0
|
||||
else:
|
||||
row.type_of_material = "Purchase"
|
||||
|
||||
if not row.lead_time and rm_details.raw_materials:
|
||||
row.lead_time = self.get_lead_time_from_raw_materials(rm_details.raw_materials)
|
||||
@@ -806,11 +804,9 @@ class MaterialRequirementsPlanningReport:
|
||||
)
|
||||
|
||||
row.capacity = 0
|
||||
row.type_of_material = get_type_of_material(material.get("is_purchase_item"), material.bom_no)
|
||||
if material.raw_materials:
|
||||
row.capacity = get_item_capacity(material.item_code, self.filters.bucket_size)
|
||||
row.type_of_material = "Manufacture"
|
||||
else:
|
||||
row.type_of_material = "Purchase"
|
||||
|
||||
self.update_required_qty(row)
|
||||
|
||||
@@ -895,13 +891,22 @@ class MaterialRequirementsPlanningReport:
|
||||
item_wise_rm_details[item_code] = frappe.db.get_value(
|
||||
"Item",
|
||||
item_code,
|
||||
["default_bom as bom_no", "safety_stock", "min_order_qty", "purchase_uom"],
|
||||
[
|
||||
"default_bom as bom_no",
|
||||
"safety_stock",
|
||||
"min_order_qty",
|
||||
"purchase_uom",
|
||||
"is_purchase_item",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
item_data = item_wise_rm_details[item_code]
|
||||
if details := get_item_details(item_code, self.filters.get("company")):
|
||||
item_data.update(details)
|
||||
|
||||
item_data.lead_time = get_item_lead_time(
|
||||
item_code, "Manufacture" if item_data.bom_no else "Purchase"
|
||||
item_code, get_type_of_material(item_data.is_purchase_item, item_data.bom_no)
|
||||
)
|
||||
|
||||
if item_code not in self.fg_items:
|
||||
@@ -940,9 +945,10 @@ class MaterialRequirementsPlanningReport:
|
||||
|
||||
if material.bom_no:
|
||||
material.raw_materials = self.get_raw_materials(material.bom_no, indent + 1)
|
||||
material.lead_time = get_item_lead_time(material.item_code, "Manufacture")
|
||||
else:
|
||||
material.lead_time = get_item_lead_time(material.item_code, "Purchase")
|
||||
|
||||
material.lead_time = get_item_lead_time(
|
||||
material.item_code, get_type_of_material(material.get("is_purchase_item"), material.bom_no)
|
||||
)
|
||||
|
||||
return raw_materials
|
||||
|
||||
@@ -1190,10 +1196,17 @@ class MaterialRequirementsPlanningReport:
|
||||
return convert_to_daily_bucket_data(sales_data)
|
||||
|
||||
|
||||
def get_type_of_material(is_purchase_item, bom_no):
|
||||
return "Purchase" if is_purchase_item and not bom_no else "Manufacture"
|
||||
|
||||
|
||||
@frappe.request_cache
|
||||
def get_item_details(item_code, company):
|
||||
data = frappe.db.get_value(
|
||||
"Item", item_code, ["safety_stock", "min_order_qty", "purchase_uom"], as_dict=True
|
||||
"Item",
|
||||
item_code,
|
||||
["safety_stock", "min_order_qty", "purchase_uom", "is_purchase_item"],
|
||||
as_dict=True,
|
||||
) or frappe._dict({"safety_stock": 0})
|
||||
|
||||
default_data = frappe.db.get_value(
|
||||
@@ -1313,6 +1326,7 @@ def make_order(selected_rows: str | list, company: str, warehouse: str | None =
|
||||
purchase_orders = {}
|
||||
work_orders = []
|
||||
covered_rows = 0
|
||||
missing_bom = []
|
||||
for row in selected_rows:
|
||||
row = frappe._dict(row)
|
||||
# what is left to order once stock and the orders already placed are counted. rounding
|
||||
@@ -1325,8 +1339,14 @@ def make_order(selected_rows: str | list, company: str, warehouse: str | None =
|
||||
if row.type_of_material == "Purchase":
|
||||
purchase_orders.setdefault((row.default_supplier, row.release_date), []).append(row)
|
||||
|
||||
if row.type_of_material == "Manufacture" and row.bom_no:
|
||||
work_orders.append(row)
|
||||
if row.type_of_material == "Manufacture":
|
||||
if row.bom_no:
|
||||
work_orders.append(row)
|
||||
elif row.item_code not in missing_bom:
|
||||
missing_bom.append(row.item_code)
|
||||
|
||||
if missing_bom:
|
||||
frappe.throw(_("Default BOM for {0} not found").format(", ".join(missing_bom)))
|
||||
|
||||
if not purchase_orders and not work_orders:
|
||||
frappe.msgprint(
|
||||
|
||||
@@ -89,6 +89,68 @@ class TestMaterialRequirementsPlanningReport(ERPNextTestSuite):
|
||||
# 1440 / 7 + 2 = 207.714...; a truncating integer division on Postgres would give 207.
|
||||
self.assertAlmostEqual(float(lead_time), 1440 / 7 + 2, places=2)
|
||||
|
||||
def test_purchase_item_without_bom_is_purchased(self):
|
||||
plan = make_mps_item(
|
||||
self,
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"is_purchase_item": 1,
|
||||
"item_defaults": [
|
||||
{"company": COMPANY, "default_warehouse": WAREHOUSE, "default_supplier": SUPPLIER}
|
||||
],
|
||||
},
|
||||
)
|
||||
self.assertEqual(plan.row.type_of_material, "Purchase")
|
||||
|
||||
make_order([plan.row], COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
|
||||
|
||||
purchase_order = get_created_order(plan.mps, "Purchase Order")
|
||||
self.assertEqual([d.item_code for d in purchase_order.items], [plan.item])
|
||||
self.assertFalse(frappe.get_all("Work Order", filters={"mps": plan.mps}, pluck="name"))
|
||||
|
||||
def test_make_order_rejects_manufactured_item_without_bom(self):
|
||||
plan = make_mps_item(
|
||||
self,
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"is_purchase_item": 0,
|
||||
"item_defaults": [{"company": COMPANY, "default_warehouse": WAREHOUSE}],
|
||||
},
|
||||
)
|
||||
self.assertEqual(plan.row.type_of_material, "Manufacture")
|
||||
self.assertFalse(plan.row.bom_no)
|
||||
|
||||
with self.assertRaises(frappe.ValidationError) as ctx:
|
||||
make_order([plan.row], COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
|
||||
|
||||
self.assertIn("Default BOM", str(ctx.exception))
|
||||
self.assertFalse(frappe.get_all("Work Order", filters={"mps": plan.mps}, pluck="name"))
|
||||
self.assertFalse(frappe.get_all("Purchase Order", filters={"mps": plan.mps}, pluck="name"))
|
||||
|
||||
def test_make_order_uses_the_bom_passed_on_the_row(self):
|
||||
plan = make_mps_item(
|
||||
self,
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"is_purchase_item": 0,
|
||||
"item_defaults": [{"company": COMPANY, "default_warehouse": WAREHOUSE}],
|
||||
},
|
||||
)
|
||||
rm_item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"is_purchase_item": 1,
|
||||
"item_defaults": [{"company": COMPANY, "default_warehouse": WAREHOUSE}],
|
||||
}
|
||||
).name
|
||||
plan.row.bom_no = make_bom(item=plan.item, raw_materials=[rm_item], rm_qty=1, rate=100).name
|
||||
|
||||
make_order([plan.row], COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
|
||||
|
||||
work_order = get_created_order(plan.mps, "Work Order")
|
||||
self.assertEqual(work_order.production_item, plan.item)
|
||||
self.assertEqual(work_order.bom_no, plan.row.bom_no)
|
||||
|
||||
def test_make_order_creates_draft_purchase_and_work_orders(self):
|
||||
plan = make_mrp_plan(self)
|
||||
|
||||
@@ -201,6 +263,47 @@ def make_chart_row(delivery_date, planned_qty=1):
|
||||
)
|
||||
|
||||
|
||||
def make_mps_item(test_case, item_properties, planned_qty=10):
|
||||
item = make_item(properties=item_properties).name
|
||||
mps = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Master Production Schedule",
|
||||
"company": COMPANY,
|
||||
"posting_date": today(),
|
||||
"from_date": today(),
|
||||
"parent_warehouse": WAREHOUSE,
|
||||
"items": [
|
||||
{
|
||||
"item_code": item,
|
||||
"warehouse": WAREHOUSE,
|
||||
"delivery_date": add_days(today(), 30),
|
||||
"planned_qty": planned_qty,
|
||||
"uom": frappe.get_cached_value("Item", item, "stock_uom"),
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
mps.insert()
|
||||
|
||||
_, data, _, _ = execute(
|
||||
frappe._dict(
|
||||
{
|
||||
"company": COMPANY,
|
||||
"from_date": today(),
|
||||
"to_date": add_days(today(), 90),
|
||||
"warehouse": WAREHOUSE,
|
||||
"mps": mps.name,
|
||||
"type_of_material": "All",
|
||||
"add_safety_stock": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
rows = [row for row in data if row.get("item_code")]
|
||||
test_case.assertTrue(rows, msg="the report returned no rows to create orders from")
|
||||
|
||||
return frappe._dict(item=item, mps=mps.name, row=rows[0], rows=rows)
|
||||
|
||||
|
||||
def make_mrp_plan(test_case, planned_qty=10, rm_qty=2):
|
||||
"""Build a finished good with a submitted BOM and an MPS demanding it, then return the
|
||||
report's own output rows -- the same payload the report's client sends to `make_order`."""
|
||||
|
||||
Reference in New Issue
Block a user