fix(manufacturing): apply mrp manufacturing buffer as a duration (#59007)

* fix(manufacturing): apply mrp manufacturing buffer as a duration

* test(manufacturing): cover mrp buffer durations
This commit is contained in:
Pandiyan P
2026-09-16 11:52:43 +05:30
committed by GitHub
parent 00ee694444
commit 2ad4a8c4a4
2 changed files with 234 additions and 55 deletions

View File

@@ -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):

View File

@@ -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: