diff --git a/erpnext/manufacturing/report/material_requirements_planning_report/material_requirements_planning_report.py b/erpnext/manufacturing/report/material_requirements_planning_report/material_requirements_planning_report.py index dbe4e5561c8..f0c43cd5980 100644 --- a/erpnext/manufacturing/report/material_requirements_planning_report/material_requirements_planning_report.py +++ b/erpnext/manufacturing/report/material_requirements_planning_report/material_requirements_planning_report.py @@ -6,7 +6,6 @@ from datetime import datetime, timedelta import frappe from frappe import _ -from frappe.query_builder import Case from frappe.query_builder.functions import Sum from frappe.utils import ( add_days, @@ -441,7 +440,6 @@ class MaterialRequirementsPlanningReport: row.indent = 0 row.bom_no = rm_details.get("bom_no") - row.lead_time = math.ceil(rm_details.get("lead_time", 0)) if not row.sales_forecast_qty: row.sales_forecast_qty = 0 @@ -463,15 +461,8 @@ class MaterialRequirementsPlanningReport: 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) - 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 - if not row.lead_time and rm_details.raw_materials: - row.lead_time = self.get_lead_time_from_raw_materials(rm_details.raw_materials) - - row.release_date = add_days(row.delivery_date, row.lead_time * -1) + self.set_lead_time(row, rm_details.raw_materials) data.append(row) if rm_details.raw_materials: self.update_rm_details( @@ -480,12 +471,36 @@ class MaterialRequirementsPlanningReport: return data - def get_lead_time_from_raw_materials(self, raw_materials): + def set_lead_time(self, row, raw_materials=None): + lead_time = get_item_lead_time(row.item_code, row.type_of_material, row.required_qty) + if ( + raw_materials + and row.required_qty > 0 + and flt(get_item_lead_time_details(row.item_code).manufacturing_time_in_mins) <= 0 + ): + lead_time += self.get_lead_time_from_raw_materials(raw_materials, row.required_qty) + + row.lead_time = math.ceil(lead_time) + row.release_date = add_days(row.delivery_date, -row.lead_time) + + def get_lead_time_from_raw_materials(self, raw_materials, qty=1): lead_time = 0 for material in raw_materials: - lead_time += math.ceil(material.lead_time) - if material.raw_materials: - lead_time += self.get_lead_time_from_raw_materials(material.raw_materials) + material_qty = material.stock_qty * qty + # Reuse descendant totals within this report, keeping different net quantities separate. + subtree_lead_times = material.setdefault("subtree_lead_times", {}) + if material_qty not in subtree_lead_times: + type_of_material = get_type_of_material(material.get("is_purchase_item"), material.bom_no) + material_lead_time = math.ceil( + get_item_lead_time(material.item_code, type_of_material, material_qty) + ) + if material.raw_materials: + material_lead_time += self.get_lead_time_from_raw_materials( + material.raw_materials, material_qty + ) + subtree_lead_times[material_qty] = material_lead_time + + lead_time += subtree_lead_times[material_qty] return lead_time @@ -781,7 +796,6 @@ class MaterialRequirementsPlanningReport: def update_rm_details(self, raw_materials, delivery_date, planned_qty, bom_no, data): for material in raw_materials: - lead_time = math.ceil(material.lead_time) row = frappe._dict( { "item_code": material.item_code, @@ -791,8 +805,6 @@ class MaterialRequirementsPlanningReport: "planned_qty": material.stock_qty * planned_qty, "projected_qty": 0, "delivery_date": delivery_date, - "lead_time": lead_time, - "release_date": add_days(delivery_date, lead_time * -1), "indent": material.indent + 1, "parent_bom": bom_no, "bom_no": material.bom_no, @@ -809,6 +821,7 @@ class MaterialRequirementsPlanningReport: row.capacity = get_item_capacity(material.item_code, self.filters.bucket_size) self.update_required_qty(row) + self.set_lead_time(row, material.raw_materials) data.append(row) @@ -905,10 +918,6 @@ class MaterialRequirementsPlanningReport: 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, get_type_of_material(item_data.is_purchase_item, item_data.bom_no) - ) - if item_code not in self.fg_items: self.fg_items.append(item_code) @@ -946,10 +955,6 @@ 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, get_type_of_material(material.get("is_purchase_item"), material.bom_no) - ) - return raw_materials def get_columns(self): @@ -1222,32 +1227,33 @@ def get_item_details(item_code, company): return data -@frappe.request_cache -def get_item_lead_time(item_code, type_of_material): - doctype = frappe.qb.DocType("Item Lead Time") - - query = frappe.qb.from_(doctype).where(doctype.item_code == item_code) - +def get_item_lead_time(item_code, type_of_material, qty=1): + """Return calendar days, scaling only manufacturing time by the required quantity.""" + details = get_item_lead_time_details(item_code) if type_of_material == "Manufacture": - query = query.select( - Case() - .when( - (doctype.manufacturing_time_in_mins.isnull() | (doctype.manufacturing_time_in_mins <= 0)), 0 - ) - .else_(1440.0 / doctype.manufacturing_time_in_mins + doctype.buffer_time) - .as_("lead_time") - ) + if qty <= 0: + return 0 + # Keep MRP's 24-hour planning day; buffer days do not increase production capacity. + time_in_days = max(flt(details.manufacturing_time_in_mins), 0) * qty / 1440.0 else: - query = query.select( - Case() - .when(doctype.purchase_time.isnull(), 0) - .else_(doctype.purchase_time + doctype.buffer_time) - .as_("lead_time") + if details.purchase_time is None: + return 0 + time_in_days = flt(details.purchase_time) + + return time_in_days + flt(details.buffer_time) + + +@frappe.request_cache +def get_item_lead_time_details(item_code): + return ( + frappe.db.get_value( + "Item Lead Time", + {"item_code": item_code}, + ["manufacturing_time_in_mins", "purchase_time", "buffer_time"], + as_dict=True, ) - - time = query.run(pluck="lead_time") - - return time[0] if time else 0 + or frappe._dict() + ) def convert_to_daily_bucket_data(data): diff --git a/erpnext/manufacturing/report/material_requirements_planning_report/test_material_requirements_planning_report.py b/erpnext/manufacturing/report/material_requirements_planning_report/test_material_requirements_planning_report.py index 8326bb4c85e..5248660bd3f 100644 --- a/erpnext/manufacturing/report/material_requirements_planning_report/test_material_requirements_planning_report.py +++ b/erpnext/manufacturing/report/material_requirements_planning_report/test_material_requirements_planning_report.py @@ -1,9 +1,11 @@ # Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt +from unittest.mock import patch + import frappe from frappe.tests.classes.context_managers import freeze_time -from frappe.utils import add_days, flt, formatdate, today +from frappe.utils import add_days, flt, formatdate, getdate, today from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom @@ -14,6 +16,7 @@ from erpnext.manufacturing.report.material_requirements_planning_report.material make_order, ) from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.tests.utils import ERPNextTestSuite COMPANY = "_Test Company" @@ -71,10 +74,8 @@ class TestMaterialRequirementsPlanningReport(ERPNextTestSuite): [formatdate(delivery_date, "dd MMM") for delivery_date in delivery_dates], ) - def test_manufacture_lead_time_is_not_int_truncated(self): - """lead_time = 1440 / manufacturing_time_in_mins + buffer_time. Both columns are Int; - integer/integer division truncates on Postgres (1440/7 -> 205) while MariaDB yields a - decimal, so the computed lead time (and the derived release date) diverged by engine.""" + def test_manufacture_lead_time_preserves_fractional_days(self): + """Manufacturing duration must retain fractional days until the report rounds it.""" item = make_item("_Test MRP Lead Time Item", {"is_stock_item": 1}).name frappe.get_doc( { @@ -86,8 +87,160 @@ class TestMaterialRequirementsPlanningReport(ERPNextTestSuite): ).insert() lead_time = get_item_lead_time(item, "Manufacture") - # 1440 / 7 + 2 = 207.714...; a truncating integer division on Postgres would give 207. - self.assertAlmostEqual(float(lead_time), 1440 / 7 + 2, places=2) + self.assertAlmostEqual(lead_time, 7 / 1440 + 2, places=8) + + @freeze_time("2026-09-01 10:00:00.123456") + def test_manufacturing_buffer_moves_release_date_earlier(self): + plan = make_mrp_plan(self, planned_qty=49, rm_qty=1) + mps = frappe.get_doc("Master Production Schedule", plan.mps) + mps.items[0].delivery_date = "2026-09-30" + mps.save() + lead_time = frappe.get_doc( + {"doctype": "Item Lead Time", "item_code": plan.fg_item, "manufacturing_time_in_mins": 30} + ).insert() + frappe.get_doc( + {"doctype": "Item Lead Time", "item_code": plan.rm_item, "purchase_time": 3, "buffer_time": 1} + ).insert() + + for buffer_days, expected_days in ((0, 2), (1, 3), (2, 4)): + with self.subTest(buffer_days=buffer_days): + lead_time.buffer_time = buffer_days + lead_time.save() + rows = get_mrp_rows(mps) + fg_row, rm_row = rows[plan.fg_item], rows[plan.rm_item] + self.assertEqual(fg_row.required_qty, 49) + self.assertEqual(fg_row.lead_time, expected_days) + self.assertEqual( + getdate(fg_row.release_date), getdate(add_days("2026-09-30", -expected_days)) + ) + self.assertEqual(rm_row.lead_time, 4) + self.assertEqual(rm_row.delivery_date, fg_row.release_date) + + def test_manufacturing_duration_boundaries_and_missing_operation_time(self): + plan = make_mrp_plan(self, planned_qty=49, rm_qty=1) + mps = frappe.get_doc("Master Production Schedule", plan.mps) + lead_time = frappe.get_doc({"doctype": "Item Lead Time", "item_code": plan.fg_item}).insert() + frappe.get_doc({"doctype": "Item Lead Time", "item_code": plan.rm_item, "purchase_time": 3}).insert() + + cases = ( + (30, 48, 0, 1), + (30, 49, 0, 2), + (30, 96, 0, 2), + (31, 47, 0, 2), + (3000, 1, 0, 3), + (30, 0.5, 1, 2), + (30, 0, 1, 0), + (0, 49, 1, 4), + (-30, 49, 1, 4), + ) + for minutes, qty, buffer_days, expected_days in cases: + with self.subTest(minutes=minutes, qty=qty, buffer_days=buffer_days): + mps.items[0].planned_qty = qty + mps.save() + lead_time.update({"manufacturing_time_in_mins": minutes, "buffer_time": buffer_days}) + lead_time.save() + row = get_mrp_rows(mps)[plan.fg_item] + self.assertEqual(row.lead_time, expected_days) + self.assertEqual(row.release_date, add_days(row.delivery_date, -expected_days)) + + def test_subassembly_buffer_uses_net_required_quantity(self): + plan = make_mrp_plan(self, planned_qty=49, rm_qty=1) + parent_item = make_item(properties={"is_stock_item": 1}).name + parent_bom = make_bom(item=parent_item, raw_materials=[plan.fg_item], rm_qty=2, rate=100) + self.assertEqual(parent_bom.items[0].bom_no, plan.bom) + mps = frappe.get_doc("Master Production Schedule", plan.mps) + mps.items[0].item_code = parent_item + mps.save() + for item in (parent_item, plan.fg_item): + frappe.get_doc( + { + "doctype": "Item Lead Time", + "item_code": item, + "manufacturing_time_in_mins": 30, + "buffer_time": 1, + } + ).insert() + frappe.get_doc({"doctype": "Item Lead Time", "item_code": plan.rm_item, "purchase_time": 3}).insert() + + rows = get_mrp_rows(mps) + self.assertEqual(rows[parent_item].lead_time, 3) + self.assertEqual(rows[plan.fg_item].required_qty, 98) + self.assertEqual(rows[plan.fg_item].lead_time, 4) + self.assertEqual(rows[plan.fg_item].delivery_date, rows[parent_item].release_date) + self.assertEqual(rows[plan.rm_item].delivery_date, rows[plan.fg_item].release_date) + + make_stock_entry(item_code=plan.fg_item, target=WAREHOUSE, qty=50, rate=100) + rows = get_mrp_rows(mps) + self.assertEqual(rows[plan.fg_item].required_qty, 48) + self.assertEqual(rows[plan.fg_item].lead_time, 2) + self.assertEqual(rows[plan.rm_item].required_qty, 48) + self.assertEqual(rows[plan.rm_item].lead_time, 3) + + def test_raw_material_fallback_reuses_subtrees_by_required_quantity(self): + plan = make_mrp_plan(self, planned_qty=49, rm_qty=1) + frappe.get_doc( + { + "doctype": "Item Lead Time", + "item_code": plan.fg_item, + "manufacturing_time_in_mins": 30, + "buffer_time": 1, + } + ).insert() + frappe.get_doc({"doctype": "Item Lead Time", "item_code": plan.rm_item, "purchase_time": 3}).insert() + + parents = [] + child_item = plan.fg_item + for _ in range(6): + parent_item = make_item(properties={"is_stock_item": 1}).name + make_bom(item=parent_item, raw_materials=[child_item], rm_qty=1, rate=100) + frappe.get_doc({"doctype": "Item Lead Time", "item_code": parent_item, "buffer_time": 1}).insert() + parents.append(parent_item) + child_item = parent_item + + mps = frappe.get_doc("Master Production Schedule", plan.mps) + mps.items[0].item_code = parents[-1] + mps.save() + with patch(f"{execute.__module__}.get_item_lead_time", wraps=get_item_lead_time) as lead_time: + rows = get_mrp_rows(mps) + # Descendant calculations should grow with the row count, not the square of BOM depth. + self.assertLessEqual(lead_time.call_count, 2 * len(rows)) + + for level, parent_item in enumerate(parents): + self.assertEqual(rows[parent_item].required_qty, 49) + self.assertEqual(rows[parent_item].lead_time, level + 7) + self.assertEqual(rows[plan.fg_item].lead_time, 3) + self.assertEqual(rows[plan.rm_item].lead_time, 3) + + # Stock changes the quantity below this assembly after the ancestor fallback was calculated. + make_stock_entry(item_code=parents[2], target=WAREHOUSE, qty=1, rate=100) + rows = get_mrp_rows(mps) + self.assertEqual(rows[parents[-1]].lead_time, 12) + self.assertEqual(rows[parents[2]].required_qty, 48) + self.assertEqual(rows[parents[2]].lead_time, 8) + self.assertEqual(rows[plan.fg_item].required_qty, 48) + self.assertEqual(rows[plan.fg_item].lead_time, 2) + + def test_manufactured_component_without_bom_keeps_buffer_duration(self): + plan = make_mrp_plan(self, planned_qty=49, rm_qty=1) + item = frappe.get_doc("Item", plan.rm_item) + item.is_purchase_item = 0 + item.save() + frappe.get_doc( + { + "doctype": "Item Lead Time", + "item_code": plan.rm_item, + "manufacturing_time_in_mins": 30, + "buffer_time": 1, + } + ).insert() + + rows = get_mrp_rows(frappe.get_doc("Master Production Schedule", plan.mps)) + component = rows[plan.rm_item] + self.assertEqual(component.type_of_material, "Manufacture") + self.assertFalse(component.bom_no) + self.assertEqual(component.lead_time, 3) + self.assertEqual(rows[plan.fg_item].lead_time, 3) + self.assertEqual(component.delivery_date, rows[plan.fg_item].release_date) def test_purchase_item_without_bom_is_purchased(self): plan = make_mps_item( @@ -376,6 +529,26 @@ def make_mrp_plan(test_case, planned_qty=10, rm_qty=2): ) +def get_mrp_rows(mps): + # Changing settings and refreshing the report happens in separate requests in Desk. + frappe.local.request_cache.clear() + _, rows, _, _ = execute( + frappe._dict( + { + "company": mps.company, + "warehouse": mps.parent_warehouse, + "mps": mps.name, + "from_date": mps.from_date, + "to_date": mps.to_date, + "type_of_material": "All", + "bucket_size": "Daily", + "add_safety_stock": 0, + } + ) + ) + return {row.item_code: row for row in rows if row.get("item_code")} + + def get_ordered_items(doctype, order): child_doctype = "Purchase Order Item" if doctype == "Purchase Order" else None if not child_doctype: