mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-20 03:47:11 +00:00
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:
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user